Skip to content

fix(native): honour inherits: false on registered custom properties - #412

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/non-inheriting-custom-properties
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/non-inheriting-custom-properties

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

@property { inherits: false } stops a custom property at the element that declares it (css-properties-values-api-1 §3). On main the native runtime inherits every custom property, so a descendant resolves a value it must not see.

Every Tailwind v4 shadow-* utility, shadow-none included, composes var(--tw-ring-shadow). So a shadow-none element inside a ring-2 ring-red-500 ancestor paints the ancestor's ring.

The value reaches descendants through every channel:

  • a stylesheet rule's variables;
  • an inline vars();
  • VariableContextProvider;
  • :root, which reaches every element;
  • the compile-time inliner, which folds a property declared once into every consumer, so a property declared on one class reaches elements without that class: a ring-2 element also paints shadow-sm's shadow.

Two more defects on main:

  • An @property rule's initial-value is written to the :root slot, so source order decides between a :root declaration and the property's default.
  • The variable registries survive from one jest test to the next.

Solution

  • Compiler.
    • The stylesheet carries the non-inheriting names (vn) and each registration's initial value (vi), apart from :root's variables (vr).
    • A registration whose initial value depends on the element (font- or container-relative units, inside calc() and transforms too) is ignored, as browsers do.
    • The inliner leaves a non-inheriting property to the runtime, unless a universal rule declares it, which every element does for itself.
  • Runtime.
    • assignInheritedVariables withholds a non-inheriting property on every channel that publishes variables to descendants.
    • varResolver skips :root for one, and reads the registered initial value last, before the var() fallback.
  • Reload. inject replaces both halves of every registration. A deleted initial value is retracted through its observable, so a mounted element updates.
  • Registries. Both are pinned to globalThis, like the style collection, so a second copy of the module reads the same ones. The jest preset resets them between tests.

Tests

  • src/__tests__/compiler/property.test.ts:
    • the vn and vi slots
    • the last registration of a name wins
    • an element-relative initial value is ignored, across the unit families, math functions and transforms
    • the inliner, for a class and for a universal rule
  • src/__tests__/native/non-inheriting-variables.test.tsx:
    • each channel: a rule, the provider, :root, and * both inlined and at runtime
    • reload and retraction
    • a second copy of the module
    • the Tailwind ring and shadow composition
  • src/__tests__/native/vars.test.tsx: an inline vars().
  • src/__tests__/native/variable-cycles.test.tsx: a cycle through :root.
  • Every expectation is what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS. For a container-relative initial value, Chromium and WebKit ignore the rule and Firefox keeps it; this follows the two.
  • On main, 59 of the 113 cases in these files fail. Each of 29 mutations fails its own cases.

Verification

On this head, every ci.yml job but the iOS build passes on Ubuntu with Node 22.18: build · lint · typecheck · yarn test 1431 passed · node --test · the web export · no unstaged files · commitlint. On Windows with Node 26, yarn typecheck, yarn lint and yarn test pass too.

The iOS dev build, run with Xcode 27.1 where CI pins 26.6, fails on main as on this head, before any JavaScript is bundled: Expo's ExpoModulesJSI does not build with it (cannot find type '__ObjC::expo' in scope).

No issue here or in nativewind/nativewind tracks this; I searched both for inherits, @property and ring.

Device evidence, before and after: #412 (comment)

Known limits

  • An @property rule lightningcss rejects aborts the whole compile, where a browser ignores just that rule: an initial value that does not match the syntax, or a missing inherits. fix(compiler): recover from a rule lightningcss cannot parse #493 recovers from it.
  • lightningcss accepts calc(1 + 1px) as a <length> and simplifies calc(3px * sign(1em)) to 3px before the compiler sees either, so each registers here where Chromium and Firefox ignore the rule.
  • An @property nested in @media registers in browsers while the query matches. lightningcss parses it as an unknown rule, so it is not registered here.
  • The inliner still folds an inheriting property declared once on a class, or under a @media query, into every consumer. fix(compiler): scope single-definition variable inlining to the declaring block #413 scopes it to the declaring block.

Merge order

This overlaps #388, #391, #392, #411, #413, #415, #423, #426, #431, #434, #436, #443, #467, #469, #484, #485, #486, #494, #496, #498, #501 and #503; I'll re-cut whichever lands after the others.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The inner box paints a ring too — a non-inheriting registered property reached a descendant.

FIXED — One ring, on the outer box. The inner box carries shadow-none and declares no ring.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch from adc1691 to 7ffd379 Compare October 11, 2026 12:35
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 16, 2026 13:17
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 13:18
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch from 7ffd379 to cdf6e28 Compare October 22, 2026 12:41
@Property { inherits: false } stops a custom property at the element that
declares it (css-properties-values-api-1 §3). The native runtime inherited
every custom property, so a descendant resolved a value it must not see.
Every Tailwind v4 shadow-* utility composes var(--tw-ring-shadow), so a
shadow-none descendant painted its ancestor's ring.

The compiler publishes the non-inheriting names (vn) and each registration's
initial value (vi), separate from :root's variables, since a declaration
wins over a property's default wherever the @Property rule sits. A
registration whose initial value depends on the element (em, rem, ch, lh,
cq* units, inside calc() or a transform too) is ignored, as browsers do.

The inliner folds a single declaration into every consumer, so a
non-inheriting property declared once on a class reached elements without
that class: a ring-2 element painted shadow-sm's shadow. It now leaves a
non-inheriting property to the runtime unless a universal rule declares it,
which every element does for itself.

At runtime every channel that publishes variables to descendants (a
stylesheet rule, an inline vars(), VariableContextProvider) withholds a
non-inheriting property, :root no longer reaches one, and the registered
initial value is the last rung before the var() fallback. A reload replaces
both halves of every registration, retracting a deleted initial value through
its observable so mounted elements update, and the jest preset resets the
registries between tests. Both registries are pinned to globalThis, like the
style collection, so a second copy of the module reads the same ones.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/non-inheriting-custom-properties branch from cdf6e28 to 0e1b564 Compare October 23, 2026 16:10

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant